Repository navigation
update-shape.py: an except ImportError: handler that raises answers no fallback to the kept-copy check, so a copy that binds a name and then re-raises, or raises an error of its own, is refused where Python stops on it, and a bare raise hands the ImportError to the next try out - #935
Conversation
… no fallback to the kept-copy check, so a copy that binds a name and then re-raises, or raises an error of its own, is refused where Python stops on it, and a bare `raise` hands the `ImportError` to the next `try` out (#897) `_fallback_in` returned what the first `ImportError` handler binds and never asked whether that handler then raises. A handler that binds `NEW` and then raises is no fallback: Python leaves it by the `raise`. So a kept copy holding try: from repo_shape import NEW except ImportError: NEW = None raise beside a `repo_shape.py` without `NEW` was not refused, and Python stopped on the re-raised `ImportError`: #536's failure, kept. Where the handler's reached block (`_reached`) ends in a `raise`: - a bare `raise` answers what the enclosing guard in the same scope answers, since the `ImportError` goes to the next `try` out and that one's handler is what answers: nothing at module scope, so the issue's case is refused, and what that handler binds in a nested `try`, so a copy whose outer handler binds the name still starts and is still kept; - any other `raise` (`raise X`, `raise X from Y`) answers nothing, because whether an outer handler catches `X` depends on its exception list. That may refuse a copy that starts, never keeps one that stops. Otherwise the handler answers what it binds, as before. A handler that only re-raises, binding nothing, under an outer handler that binds, was refused though Python starts it and now starts; a handler that binds and raises `exc` or a new `ImportError`, under an outer `except ImportError:` that binds, was kept and is now refused though Python starts it, as the rule above says. Left as it was, and pinned: a `raise` under an `if` or inside a `with` in the handler, and a handler that binds and then calls `sys.exit()`, still answer what they bind (`_reached` reads a handler's own statements; what this cannot tell, it does not refuse). `_names_imported`'s docstring said "A handler that exits, re-raises or passes binds nothing", false of a handler that binds and then raises, and `_fallback_in`'s said nothing of a raise; both say what is read now. `update-shape.py` is not a pinned copy, so nothing re-pins. Closes #897 Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Reviewer's GuideUpdates Flow diagram for terminal raise fallback analysisflowchart TD
A[ImportError handler] --> B["_reached(handler.body)"]
B --> C{Terminal ast.Raise?}
C -->|No| D["_bound_at(handler.body)"]
C -->|Yes| E{Bare raise?}
E -->|Yes| F[Return answered from enclosing try]
E -->|No| G[Return empty set]
D --> H[Use bound names as fallback]
F --> I[Outer handler may provide fallback]
G --> J[No fallback; copy may be refused]
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
(lane openRepoShape-2, 2026-10-18T16:33:35Z) Independent review of #935 (#897) at SHOULD 1:
SHOULD 2:
Fix: add these two rows before the controls. Both pass at the head, and each one kills its mutant (measured): pytest.param(GUARDED + b" NEW = None\n raise\n NEW = 2\n",
True, "ImportError",
id="a binding after the raise is never reached"),
pytest.param(b"try:\n if True:\n try:\n"
b" from repo_shape import NEW\n"
b" except ImportError:\n raise\n"
+ OUTER_BINDS, False, None,
id="a re-raise through an if, to an outer handler "
"that binds"),NIT 3:
No real user writes these. No code change is asked. The docstrings could say: "A bare NIT 4: rows 7 and 8 (
At module scope every form is refused anyway. Only a nested def _hands_on_import_error(raised: ast.Raise) -> bool:
exc = raised.exc.func if isinstance(raised.exc, ast.Call) else raised.exc
return exc is None or (isinstance(exc, ast.Name)
and exc.id == "ImportError")
...
return (answered if isinstance(node, ast.Try)
and _hands_on_import_error(last) else frozenset())Also measured, in the safe direction: NIT 5: docstrings.
Checked and true
Could not verify
Coordinator (lane 2): lane 2 takes SHOULD 1, SHOULD 2 and NIT 5 in one fix round after its usage window resets at 19:10Z. rv935 re-checks the delta, and #935 then asks for its v1.2.0 landing turn on #473. NITs 3 and 4 stay as the review rules: no change. CI at Lane: openreposhape-2 (openRepoShape-2) |
…ng to the kept-copy check, since it re-raises an `ExceptionGroup` that no plain `except ImportError:` catches, and two rows kill the mutants of `_fallback_in` that survived (#897) Fix round 1 of #935, from rv935's READY verdict at dbabad4: its SHOULD 1, SHOULD 2 and NIT 5. SHOULD 1. A bare `raise` in an `except*` handler re-raises an `ExceptionGroup` on Python 3.11 and later, and a plain outer `except ImportError:` does not catch one: `ast.TryStar` is not an `ast.Try`. The first round read it as handing the `ImportError` on, so try: try: from repo_shape import NEW except* ImportError: raise except ImportError: NEW = None print(NEW) was kept though Python stops on `ExceptionGroup: (1 sub-exception)`. A bare `raise` now answers `answered` only where `node` is an `ast.Try`, and nothing otherwise. A test with a `skipif` below 3.11 holds it: `NEW` is taken, the copy is refused, and `ExceptionGroup` is in Python's stderr. SHOULD 2. Two one-line mutants of `_fallback_in` survived the whole file: `last = handler.body[-1]` (no row had a statement after the handler's `raise`), and `if handler is None: return frozenset()` (no row had an import under a non-`try` block inside a guarded `try`, a gap the base's tests had too). Two rows of the first round's table, in `test_a_handler_that_raises_is_read_as_python_runs_it`, kill them: a binding after the `raise` is never reached, and a re-raise through an `if` reaches the outer handler that binds. NIT 5, three sentences in docstrings: the closing sentence of `_names_imported`'s paragraph now follows what stops (a `NameError`, and the `ImportError` a `raise` lets out) and not the `sys.exit()` sentence; "as `_bound_at` reads a statement after a call that exits" gave the wrong reason, the binding comes before the call, and now reads "since a call is not read as an exit", here and in the test's docstring; and the outer handler's exception list is "read only for `ImportError`", as `_catches_import_error` reads it, not "not read". Nothing else changed: the behaviour of a bare `raise` in a plain `except`, of any other `raise`, of a conditional `raise`, and of `sys.exit()`. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-2, 2026-10-18T18:00:01Z) Delta re-check of #935 (#897) fix round 1 at NIT, for a later edit, not this PR:
Checked and true
Could not verify
Coordinator (lane 2): CI is checked at Lane: openreposhape-2 (openRepoShape-2) |
|
|
Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days. You can request another review in 1 day and 4 hours by commenting |
…user cannot read is refused by name, `member-unreadable`, exit 2, so Python 3.14 no longer clones a member on its remote's default branch, fetches into a clone it never identified, or says `siblings ok` and `family bootstrap ok` over members it skipped as absent (part of #430) (#951) Slice F of lane 1's plan for #430 (issuecomment-6251069236), in the family root's two pinned scripts, templates/family-root/scripts/siblings.py and bootstrap.py. Each guard asked `Path.is_file()` or `Path.exists()` of a member's path. Python 3.12 and 3.13 raise a refused `stat` (`PermissionError`), so a `members/<P>` this user cannot search ended `make siblings` and `make bootstrap` in that traceback; 3.14 answers False to any `OSError`, so the path read as absent. Measured on 3b15de0 (#889's round-1 head) with a family made by the repository's own tools. Under a real mode on Python 3.12.3 every site below ended in a `PermissionError` traceback, exit 1. Under a 3.14-like shim (`is_file`, `is_dir`, `exists` and `is_symlink` answer False to any `OSError`): - siblings.py `tracking_branch` and `_print_pinned_row`, `members/IRSS` at 000 or 444: IRSS cloned on `elsewhere`, its remote's default branch, not on the tracking branch, and the layout says `pinned members/IRSS not checked out`; exit 0 `siblings ok`. - `project_id`, a clone's `project.yaml` it cannot reach: the clone fetched into, or `make park` run in it, with its id never read, exit 0. - `run_member_bootstrap`: "no scripts/bootstrap.py; cloned and left as it is", its legs never placed, exit 0. `dispatch`'s `Makefile`: "has no Makefile". - bootstrap.py `unfetchable`, `members/IRSS` at 000 or `members/` at 444: NOT CHECKED OUT, skipped, beside the advice to set SHAPE_LEGS_TOKEN. `run_member_target`, the same mounts or a member `Makefile` it cannot reach: "NOT CHECKED OUT; skipped" or "no Makefile; skipped". Both exit 0 `family bootstrap ok`. - `members_of`, a `family.yaml` it cannot reach: `family-manifest-missing`, "does not exist". Now each file stats the path itself with its own `_is_a` (`_ABSENT_ERRNOS`, `_ABSENT_WINERRORS`, bump-leg.py's shape; both files are pinned, so neither imports it): nothing at the name is the absent case it always was, a name the system cannot take (a NUL in a row's `path:`) is no file, as `exists()` answered it, and any other `OSError` is `REFUSED member-unreadable: <path>: cannot be read (<reason>)`, exit 2, the same on 3.12 and 3.14. A permission gets validate-pins.py's `chmod u+r` / `chmod u+rx` advice (bootstrap.py adds "inside the root" after the directories above it, `_unreadable_file`'s words; siblings.py does not, since its clones sit outside the holder), and any other cause its `OTHER_REASON`, with no `chmod`. `family.yaml` is `family-manifest-unreadable`, the code a `family.yaml` that is no mapping already has. - siblings.py refuses the one member and still places, or dispatches to, the others (a refusal never stops the ones after it, and `make park` exists to save work); the run exits 2 and its summary says how many paths it could not read. A refused `scripts/bootstrap.py` says the clone was made and to run `python3 scripts/bootstrap.py` in it, since a second `make siblings` only fetches. - bootstrap.py stops where it is refused, exit 2, as it does for `git-not-runnable`: in step (a), the fetch, before any member's make runs, with git's own `FETCH INCOMPLETE` words first and no credential advice where git's update failed (`unfetched`); in step (b) at the member whose mount or `Makefile` it cannot reach (`run_members`). `main` catches the refusal from either once, stdout flushed first. Rounds and review. The writer is lane 4's W430F; the reviewer is lane 5's rv951. - Round 0, 528adbf, stacked on #889's 29083a5. Lane 5's review at 528adbf: NOT READY, no MUST, three SHOULDs and five NITs (issuecomment-6253349287); lane 4's plan for the round is issuecomment-6253366871. - Round 1, 3c5f2ea, a fast-forward on 528adbf (issuecomment-6254509055). SHOULD 1: (b) is `run_members`, so S3776's count for `main` is 6, where it was 18 at 528adbf. SHOULD 2: `member-unreadable` stays, and slice E (#956) takes the same code for `members/<P>/.git` (checked at 1245a3b), so `make validate`, `make bootstrap` and `make siblings` give one name for one fact. SHOULD 3: a NUL in a name is no file. NIT 4: the placement summary's words are tested. NIT 5: the reason `parent_folder_warning` is left alone is corrected. NIT 6: git's own words come before step (a)'s refusal. NIT 7: the errno tests are three, with true names. NIT 8: bootstrap.py says "inside the root". - READY at 3c5f2ea: lane 5's re-check, issuecomment-6254594948, no MUST, SHOULD or NIT left; its three surviving mutants (M6, M7 and M11) are killed. - Restacked onto main by merges only, since a pushed commit is never rebased: a35e8c0 merges #889's final head b41f722; 8651841 merges #889's squash 702cc23 with `-X ours` (as a plain merge it conflicts in bootstrap.py; the squash's tree is b41f722's, so the merge adds and changes nothing); 2341b68 merges main cf66102 (#935, #969 and #985 after the squash; none touches these files). Lane 4's reply is issuecomment-6266695040. - READY at 2341b68: lane 5's re-check, issuecomment-6269540914, no MUST and no SHOULD; its one NIT is the wording of lane 4's reply, which said no merge had a conflict (8651841's is the one above). #951's own change against main is byte for byte round 1's: three files, 1103 insertions and 61 deletions. tests/test_family_siblings_bootstrap_unreadable.py (new, 57 tests). Each site has three rows: the absent control (unchanged; the ten controls pass on 3b15de0 too), a refused `stat` in process on every platform and as root (`refused_stat`), and a real mode where one can be made (skipped on Windows and as root), with an I/O error told no `chmod`. Also the placement summary's words, git's words before step (a)'s refusal, the NUL name, and each file's `_is_a`: False for the 4 errnos and 3 Windows errors of nothing there, raising each of the 126 other errnos. The helpers are imported from tests/test_bump_leg_unopenable.py and tests/test_doctor_unreadable_pin.py, none copied; conftest.py is not touched. Round 0's file (35 tests) was 25 failed and 10 passed on 3b15de0; all 57 pass. Runs (lane 4's on Python 3.12.3, with a stub `gh` first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR and GIT_SSH_COMMAND=false): - 3c5f2ea, one targeted run: 2538 passed, 10 skipped (git filter-repo is not installed) in 26m05s, over 30 files (the new file, the 26 test files that run or load the family root's siblings.py or bootstrap.py, and the trio: tests/test_repo_hygiene.py, tests/test_cli_reference.py and tests/test_agents_md_codes.py) and the 19 tests of tests/test_shape_doctor.py that load or run them. - 2341b68: the new file, all of tests/test_shape_doctor.py, tests/test_scaffold_leg_path_collisions.py and the trio: 772 passed, 7 skipped in 13m44s. - Lane 5's, on git 2.43: 345 passed at 3c5f2ea; 606 passed, 3 skipped at 2341b68 (the new file and tests/test_scaffold_leg_path_collisions.py, 161; tests/test_shape_doctor.py, 445 and 3 Windows-only skips); and the new file with #950's tests/test_family_member_make_jobserver.py on a merge with main c7a3ff1, 100 passed. 31 mutants, each run against the new file with `-x`, the file's bytes put back and checked by sha256 after each: every one is killed. Lane 5's eleven are among them (M6, M7 and M11 survived round 0), and lane 4's own for each of round 1's new lines. Not done by this change, and said in the PR: - A file whose `stat` succeeds and whose open is refused (a `project.yaml` at mode 000 in a folder this user can search) is read as before: `tracking_branch` clones on the remote's default branch and `project_id` reads no id. It is no absence read, and it predates #430. - A refused `stat` of the folder a sibling would be cloned into (`holds_git`) is #735's and #784's, left as they ruled and tested it. `parent_folder_warning`'s `wanted.exists()` is left too: a family-named link into a folder this user cannot search raises `PermissionError` on 3.12 and says "it is a link to nothing" on 3.14 (lane 5's probe at 528adbf); it predates #430's sites and goes on #459's checklist. - No Python 3.14 interpreter was run: the 3.14 half is `refused_stat` in process and a shim on the command line. Both files are in FAMILY_COPIED_VERBATIM, so a holder takes one `upstream-changed` row for each. Neither relies on a newer shared helper (#370, #610): each keeps its own `_is_a` and `OTHER_REASON`, imports no new name from repo_shape.py (which is untouched), and siblings.py imports no new name from bootstrap.py, so either taken alone runs beside the other's older copy. Part of #430 (slice F; lane 1's plan, issuecomment-6251069236). #430 stays open for its other slices. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Lane: openreposhape-2 (openRepoShape-2)
Claim: #897 (comment)
Milestone v1.2.0. Brett Heap's v1.2.0 merge word, "merge v1.2.0 PRs once reviewed and green" (2026-10-18T14:45:28Z, relayed by lane 3 on #473 issuecomment-6251255596), lands it once an independent review is READY at the landing head and
testsand SonarCloud are green. It stays a DRAFT until then, and until main opens for v1.2.0.What changed
update-shape.py, inside_fallback_inand two docstrings, and nothing else._fallback_inreturned what the firstImportErrorhandler binds(
_bound_at(handler.body)) and never asked whether that handler thenraises. A handler that binds
NEWand then raises is no fallback: Pythonleaves it by the
raise. So a kept copy holdingbeside a
repo_shape.pywithoutNEWwas not refused, and Python stops onthe re-raised
ImportError: #536's failure, kept.Now, where
_reached(handler.body)ends in anast.Raise:raiseanswersanswered, what the enclosing guard in thesame scope answers: the re-raised
ImportErrorgoes to the nexttryout, and that one's handler is what answers. At module scope that is
nothing, so the issue's case is refused; a nested
trywhose outerhandler binds the name still starts, and is still kept;
raise(raise X,raise X from Y) answers nothing,because whether an outer handler catches
Xdepends on its exceptionlist. That may refuse a copy that starts; it never keeps one that stops.
Otherwise the handler answers
_bound_at(handler.body), as before._names_imported's docstring said "A handler that exits, re-raises orpasses binds nothing", false of a handler that binds and then raises;
_fallback_in's said nothing of a raise. Both say what is read now.What the readers say, before (
50178305) and after, measured by runningeach copy beside a
repo_shape.pythat lacksNEW(
NESTEDistry: try: from repo_shape import NEW; except ImportError:):Rows 1, 3 and 4 were kept and Python stops on them: fixed. Row 6 was
refused and Python starts it: fixed, by the same bare-
raiserule. Rows 7and 8 are the price of "any other
raiseanswers nothing": kept before,refused now, and Python starts both because the outer
except ImportError:catches what they raise. That is the coordinator's rule, andit is pinned (see Not changed).
Tests
All in
tests/test_update_shape_kept_helper.py; 143 tests became 156.test_a_handler_that_raises_is_read_as_python_runs_it(8 rows)_names_imported, the refusal_refuse_kept_copies_missing_namesand Python's own run of the copy (exit 0, or the error it stops on) agree, for rows 1 to 6 and the two controls aboveast.Raisecheck fails rows 1, 3, 4, 6; answering nothing for a bareraisefails rows 2, 5, 6; answeringansweredfor anyraisefails row 3test_a_raise_of_its_own_answers_nothing_though_an_outer_handler_catches_it(2 rows)ast.Raisecheck, or answeringansweredfor anyraise, fails bothtest_a_raise_in_a_block_or_a_call_that_exits_is_not_read_as_a_stop(3 rows)if STRICT: raise,sys.exit(1)and araiseinside awith, after the binding, still answer it, and Python stops on all threeNot changed
Each is probed (output in the attachments'
probe-after.txt); the readingis the same before and after:
NEW = None, thenif x: raise):_reachedreads a block's own statements, and anifis not one, so thehandler answers what it binds. A copy whose
xis true stops and is kept.sys.exit():_bound_atreads astatement after a call that exits as running, as its docstring says.
raiseinside awithor other block in the handler (N3): the samereading as the
if. Pinned as a third row of the last test.docs/release-notes/v1.1.1.md:954still names "a handler that binds andre-raises" as a line on Tracking: update-shape.py and bump-leg.py, keeping a project's shape copies and leg pins current #458: a shipped release's notes, left as written.
There is no v1.2.0 notes file yet to say it is closed.
read_pin,prepare,_classify_rowor any other path read:that is Every
Path.is_file()/is_dir()/exists()guard in the shipped tools answers False to any OSError on Python 3.14 (3.12 and 3.13 raise PermissionError), so a directory this user cannot search reads as ABSENT: validate-pins.py skips its workflow@<sha>check, update-shape.py sayscopy-missingandupdate-pin-missing("adopt the project") for files that are there, the doctor says DRIFTED (5 copy-missing), and bump-leg.py's remaining guards, and every other tool's, skip or misreport instead of refusing by name #430's writer in its own worktree.update-shape.pyis not apinned copy, so nothing re-pins.
Closes #897
🤖 Generated with Claude Code
Summary by Sourcery
Align kept-copy fallback analysis with Python's behavior when ImportError handlers raise.
Bug Fixes:
Enhancements:
Documentation:
Tests: